Skip to content

Membrane stretch - #7084

Open
Patryk26g wants to merge 20 commits into
masterfrom
experimental-membrane-stretch
Open

Membrane stretch#7084
Patryk26g wants to merge 20 commits into
masterfrom
experimental-membrane-stretch

Conversation

@Patryk26g

@Patryk26g Patryk26g commented Jun 28, 2026

Copy link
Copy Markdown
Contributor

Brief Description of What This PR Does

Extends membrane in multicellular colonies to other cell neighbours.

Membrane is generated in 2 passes - first normally like for single cell expect without applying membrane waviness. Then in the second pass, which is currently performed on a single thread, gets cells neighbours.,calculates the position of middle point between these cell, creates tangent lines to the cell coming from that point and casts vertices onto these lines checking if the are not accidentally inside other neighbours' membrane. After that a smoothing is applied.

Note: membrane stretching in a colony is sequential for each cell, not asynchronous. It's because it needs other cells' membranes (both original and stretched) to avoid overlaps. Also during calculations both current and neighbours membranes are modified to avoid cases where one cell's vertices are modified and second's not because of overlaps. The code makes sure that either both stretch or none do.

Unfortunately there are list allocations for rotated and shifted neighbours vertices. Maybe I should use array pool there?

Related Issues

Progress Checklist

Note: before starting this checklist the PR should be marked as non-draft.

  • PR author has checked that this PR works as intended and doesn't
    break existing features:
    https://wiki.revolutionarygamesstudio.com/wiki/Testing_Checklist
    (this is important as to not waste the time of Thrive team
    members reviewing this PR). This includes gameplay testing by the PR author.
  • Initial code review passed (this and further items should not be checked by the PR author)
  • Functionality is confirmed working by another person (see above checklist link)
  • Final code review is passed and code conforms to the
    styleguide.

Before merging all CI jobs should finish on this PR without errors, if
there are automatically detected style issues they should be fixed by
the PR author. Merging must follow our
styleguide.

@revolutionary-bot

Copy link
Copy Markdown

The lead programmer for Thrive is currently on vacation until 2026-07-13. Until then other programmers will try to make pull request reviews, but please be patient if your PR is not getting reviewed.

PRs may be merged after multiple programmers have approved the changes (especially making sure to ensure style guide conformance and gameplay testing are good). If there are no active experienced programmers who can perform merges, PRs may need to wait until the lead programmer is back to be merged.

@github-project-automation github-project-automation Bot moved this to In progress in Thrive Planning Jun 28, 2026
@Patryk26g
Patryk26g marked this pull request as ready for review July 6, 2026 17:43
@hhyyrylainen

Copy link
Copy Markdown
Member

Is this still experimental or, should this be marked as a draft or is this ready for review?

{
public Vector2[] HexPositions { get; }
public int HexPositionCount { get; }
public Vector2[]? MulticellularPositions { get; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding all of this extra data makes this a ton heavier, enough so that I fear for the efficiency of the overall design being able to handle things...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What exactly do you mean by "being able to handle things"? I unfortunatelly need all of this data to make good membrane stretch generation. What would you propose here instead?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The way I made the hashing and finding stuff is that I bedgrudginly accepted that the game has to hash the full list of positions, and that works fine now. But this PR increases the amount of hashing effort 5x, and makes membranes unique based on colony position, meaning that the overall caching system for membranes might not even make sense. So by adding a lot of heavy weight to the membrane caching system might hinder it so much that the entire concept needs to be rethought.

I suggested a few things to try to make it lighter (by using a single list, and only verifying some properties), but there might be a fundamental limit that causes problems with doing so much new stuff in the membrane caching and generation.

@Patryk26g

Copy link
Copy Markdown
Contributor Author

Is this still experimental or, should this be marked as a draft or is this ready for review?

its ready for review

@Patryk26g Patryk26g changed the title Experimental membrane stretch Membrane stretch Jul 15, 2026
@hhyyrylainen hhyyrylainen added this to the Release 1.2.0 milestone Jul 15, 2026
continue;

var cell = multicellular.Species.ModifiableGameplayCells[i];
var cellPosistion = Hex.AxialToCartesian(cell.Position);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var cellPosistion = Hex.AxialToCartesian(cell.Position);
var cellPosition = Hex.AxialToCartesian(cell.Position);

Typo

Comment on lines +342 to +343
var positionsArray = cellsPositions.ToArray();
var rotationsArray = cellsOrientations.ToArray();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For memory efficiency, these should absolutely be avoided.

If temporary memory is needed I think these should use array buffer renting.

Or having read the generation request data, I think we should have just a single array with structs in it and those structs then would hold all data related to the other cells rather than spreading it across like 5 or so arrays as they are currently.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that also applies to the dictionaries with lists as values, right?

I dont quite understand what you mean by array with structs in it - why structs would be better in this case then classes? they would constantly be copied across all the method calls, wouldn't they?

Anyway, I will try to improve it, either using the arrayPool or somehow implementing the struct concept

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess that also applies to the dictionaries with lists as values, right?

Yes.

I dont quite understand what you mean by array with structs in it - why structs would be better in this case then classes? they would constantly be copied across all the method calls, wouldn't they?

In C# structs are value types, which means that ArrayStruct[] and List<ArrayStruct> both allocate only one continuous segment of memory. Whereas a list of class objects first allocates the list memory and then each class separately this causes memory fragmentation and major slowdown as the CPU prefetch doesn't work. So a list of classes is a memory performance nightmare in comparison to an array. Even better would be being able to rent arrays from the global array buffer and only needing to not return the array when actually generating a new membrane, this would take most of the load off from using a cached copy of data.

Anyway, I will try to improve it, either using the arrayPool or somehow implementing the struct concept

Yes, I think this would be a very key optimization.

I thought about using stackalloc originally but I couldn't figure out a way to use it because when you fill it and hash it, and find out that the membrane is not cached, then you need to make a second array you can actually give to the membrane generation object. So if our cache hit rate is low the extra copy from the stackalloc array to the array that can be given to the membrane object to hold, will be a major performance drain (and stackalloc wouldn't help), but I haven't extensively tested so I'm not actually sure if this problem is real.


var sourcePoints = dataSource.HexPositions;

if (dataSource.MulticellularPositions != null || multicellularPositions != null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks quite bad in terms of new performance drain, so I would only keep the most important comparison and put all else behind a #if DEBUG condition check to still help catch bugs but not eat up runtime performance for players.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense

continue;

var cell = multicellular.Species.ModifiableGameplayCells[i];
var cellPosistion = Hex.AxialToCartesian(cell.Position);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
var cellPosistion = Hex.AxialToCartesian(cell.Position);
var cellPosition = Hex.AxialToCartesian(cell.Position);


// TODO: already generate the 3D points here for use on the main thread for faster membrane creation?
// Use coordinator to handle both single-cell and multicellular two-pass generation.
var writtenHashes = MembraneGenerationCoordinator.HandleGenerationRequest(ref generationParameters);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns a new list? I think it would be better if HandleGenerationRequest would be given a persistent list stored in a field in this class.

Comment on lines +16 to +17
public int? CellOrientation { get; }
public long? ColonyKey { get; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two are boxed, so I think these should use a bool flag and a direct primitive type.

Also just noticed that CellPositionInMulticellular is also boxed.

Also this class still has a ton of lists, I'd prefer a single struct that combined

public struct MembraneGenerationCellData{
    public Vector2 Position;
    public int CellOrientation;
}

And then having one list with multicellular data using that struct.

{
public MembraneGenerationParameters(Vector2[] hexPositions, int hexPositionCount, MembraneType type,
Vector2[] multicellularPositions, Vector2 thisCellPosition, int[]? multicellularOrientations,
int? thisCellOrientation, long? colonyKey = null, bool isPreMulticellularStretch = false)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nullable primitives also cause boxing here, so should be avoided. Maybe with constructor overloads where the optional ones are just missing?

Comment on lines +162 to +163
hash = (hash * prime1) ^ BitConverter.SingleToInt32Bits(dataSource.HexPositions[i].X);
hash = (hash * prime1) ^ BitConverter.SingleToInt32Bits(dataSource.HexPositions[i].Y);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be more efficient to read dataSource.HexPositions[i] into a local variable rather than doing 2 array lookups.

public static bool MembraneDataFieldsEqual(this IMembraneDataSource dataSource, Vector2[] otherPoints,
int otherPointCount, MembraneType otherType)
int otherPointCount, MembraneType otherType, Vector2? cellPositionInMulticellular = null,
int? cellOrientationInMulticellular = null, long? otherColonyKey = null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boxing happening here as well.

}
}

if (dataSource.ColonyKey != null || otherColonyKey != null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking at things here, I would make it so that in release mode only the colony key is checked and the above orientation and position checks are inside a #if DEBUG preprocessor directive.

/// <summary>
/// Handles membrane generation requests. For single-cell requests the list contains one hash.
/// </summary>
public static List<long> HandleGenerationRequest(ref MembraneGenerationParameters generationParameters)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public static List<long> HandleGenerationRequest(ref MembraneGenerationParameters generationParameters)
public static void HandleGenerationRequest(ref MembraneGenerationParameters generationParameters, List<long> result)

Comment on lines +32 to +35
var multicellularPositions = generationParameters.MulticellularPositions!;
var multicellularOrientations = generationParameters.MulticellularOrientations;
var cellPosition = generationParameters.CellPositionInMulticellular!.Value;
var cellOrientation = generationParameters.CellOrientation;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These variables should be after looking in the cache as it looks these are not used in that case so that would be more efficient.

}
else
{
colonyKey = ComputeColonyKey(multicellularPositions, multicellularOrientations);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't the colony key be always present? Shouldn't this print a performance warning if it is missing?


tracker.NeighboursData[CellKey(cellPosition)] = singleCellData;

// Colony not yet complete — return empty

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably rare enough that colony trackers never finish, right? So them leaking is at most a very slow resource leak... I was thinking that maybe to fix that the membrane generation system could like every minute clear out any trackers that are inactive for 15+ seconds, but that's a relatively complex thing to add.

IReadOnlyList<Vector2> verticesToCopy)
IReadOnlyList<Vector2> verticesToCopy, Vector2 averageVertex, Vector2[]? multicellularPositions = null,
Vector2? cellPositionInMulticellular = null, int[]? multicellularOrientations = null,
int? cellOrientation = null, bool isPreMulticellularStretch = false, long? colonyKey = null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boxing happening here.

Comment on lines +103 to +109
public int? CellOrientation { get; }

/// <summary>
/// Precomputed colony key that encodes neighbour positions and rotations for caching/equality.
/// </summary>
public long? ColonyKey { get; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two cause boxing.

/// <summary>
/// Positions of other cells in multicellular organism
/// </summary>
public Vector2[]? MulticellularPositions { get; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This and the rotation could be combined into a single list with a struct like I commented already.

/// <summary>
/// Position of current cell in multicellular body plan
/// </summary>
public Vector2? CellPositionInMulticellular { get; }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Boxing here.

float distanceSquared = 0;

foreach (var vertex in Vertices2D)
for (int i = 0; i < VertexCount; ++i)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why was this changed to a loop like this? As this is a plain array, foreach doesn't allocate memory, but VertexCount as a property causes an extra method call each iteration of the loop. So if you really want to change this, read the count into a local variable before the loop.

/// </returns>
public MembranePointData GenerateShape(Vector2[] hexPositions, int hexCount, MembraneType membraneType)
public MembranePointData GenerateMicrobeShape(Vector2[] hexPositions, int hexCount, MembraneType membraneType,
bool isMulticellular = false, long? colonyKey = null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

colonyKey is being boxed here.


// Get new membrane points for vertices2D
GenerateMembranePoints(hexPositions, hexCount, membraneType);
var averagePoint = GetAverageVertex();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the average point always needed? If not I think it could be made so that it is calculated upon the first try to use it?

Comment on lines +385 to +387
for (int i = 0; i < vertexCount; ++i)
{
center += new Vector3(point.X, 0.0f, point.Y);
center += new Vector3(vertices2D[i].X, 0.0f, vertices2D[i].Y);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also potentially less efficient loop here as well especially as the array is read twice inside the loop rather than once.

return false;

// Determine winding from the first edge so we know which sign = "inside"
float firstCross = Cross(vertices[0], vertices[1], point);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd add a #if DEBUG check here to ensure that the vertices list is at least 2 items as otherwise this line throws an index out of range exception.

var ba = a - b;
var bc = c - b;

var angle = ba.AngleTo(bc) * MathUtils.DEGREES_TO_RADIANS;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't this use the wrong constant? Shouldn't this use RADIANS_TO_DEGREES? Because right now it looks like angle is made into radians and then you a line later use 180 to compare which looks to be in degrees, so there's a mismatch here.

continue;

// Perpendicular distance from vertex to the ray
float distanceToRay = (directionToVertex - direction * projection).Length();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this have to use Length? It is much, much less efficient than length squared and that still allows relative comparisons.

}

/// <summary>
/// Define from which index of verices2D to which index the points should be moved

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Define from which index of verices2D to which index the points should be moved
/// Define from which index of vertices2D to which index the points should be moved

/// <summary>
/// Define from which index of verices2D to which index the points should be moved
/// </summary>
/// <param name="vertices"> The list of vertices </param>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// <param name="vertices"> The list of vertices </param>
/// <param name="vertices">The list of vertices</param>

Comment on lines +680 to +684
/// <param name="tangentPointIndexA"> First tangent line's point on the membrane </param>
/// <param name="tangentPointIndexB"> Second tangent line's point on the membrane </param>
/// <param name="reachVertexIndex"> A point on the membrane between the tangent points </param>
/// <param name="indexStart"> The starting index for the iteration </param>
/// <param name="indexEnd"> The ending index for the iteration </param>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// <param name="tangentPointIndexA"> First tangent line's point on the membrane </param>
/// <param name="tangentPointIndexB"> Second tangent line's point on the membrane </param>
/// <param name="reachVertexIndex"> A point on the membrane between the tangent points </param>
/// <param name="indexStart"> The starting index for the iteration </param>
/// <param name="indexEnd"> The ending index for the iteration </param>
/// <param name="tangentPointIndexA">First tangent line's point on the membrane</param>
/// <param name="tangentPointIndexB">Second tangent line's point on the membrane</param>
/// <param name="reachVertexIndex">A point on the membrane between the tangent points</param>
/// <param name="indexStart">The starting index for the iteration</param>
/// <param name="indexEnd">The ending index for the iteration</param>

var averageVertex = Vector2.Zero;
foreach (var vertex in vertices2D)
averageVertex += vertex;
averageVertex /= vertices2D.Count;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that for safety this should throw if vertices2D is empty.

var vertex = editableNeighbourVertices[i];
vertex -= storedLocalOffset;
vertex = RotatePoint(vertex, -storedRelativeAngle);
neighbourData.ModifiedVertices.Add(vertex);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What ensures that multiple membrane shape generators don't write to the same modified vertices at once?


var vertexCount = neighbourData.OriginalPointData.VertexCount;

if (neighbourData.ModifiedVertices != null)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this not cause the membrane shape to be different depending on which cells get processed first?


if (currentCellData.ModifiedVertices != null)
{
for (int i = 0; i < currentCellData.OriginalPointData.VertexCount; ++i)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As these both loops run to the same count, I think extracting the count outside the if on line 962 would make sense.

return false;
}

// TODO: throw if negative

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should a negative check be added here?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: In progress

Development

Successfully merging this pull request may close these issues.

3 participants